Skip to main content
  1. Writing/

Enable Local MCP Servers To Access Entra ID Accounts

As we’re talking about improving the authorization posture for MCP servers, one thing that popped into my conversations with the community over the weekend was authentication and authorization for local Model Context Protocol (MCP) servers.

Local servers are a fun beast, not the least because they are a running, well, locally. If you look through the authorization specification for MCP, you will see quickly realize that it’s special-built for remote scenarios, but if you have something on your box, this is not as relevant. Why is that?

Well, because if you run locally - your client doesn’t need to do any kind of custom authorization dance with the server! The MCP server itself can do it solo, and prompt the user for any credentials interactively. It’s as if the responsibilities have shifted, and the client now delegates almost everything to the server.

Not everybody can easily shift into soup mode like George Costanza.
Not everybody can easily shift into soup mode like George Costanza.

In practice, this goes back to what I mentioned in an earlier blog post about the perception of MCP clients and MCP servers. A local MCP server is a binary that can do all the things a local application can do. Remember how when you installed the Xbox app from the Microsoft Store it prompted you to log in with your Microsoft Account? Or when you installed GitHub Desktop and it asked you to sign into GitHub?

A local MCP server (i.e., a local binary) can do the exact same thing when it requires user credentials in any capacity. That means that you can use tried and tested patterns here instead of re-inventing your own. Let’s take a peek at an example that I put together recently that shows how a local MCP server can:

  1. Request user credentials interactively with the help of the Web Account Manager (WAM) on Windows.
  2. Cache said credentials locally.
  3. Call a protected API with said credentials.
The example assumes that your application is a public client application (PCA). PCAs cannot securely hold a secret within them, which means that you must not use any kinds of Entra ID client secrets when working with local MCP servers like the one in this demo.

The set up #

To get things up and running, I decided to use the MCP C# SDK. Additionally, to make things easier for myself (and for anyone else that wants to deal with Entra ID user authentication), I am using Microsoft Authentication Library (MSAL) in my project.

In my project, I defined a single tool. It’s task is very basic - get user details from Microsoft Graph. The user, in this context, is the authorized user - the one that will provide their credentials when we interactively request them:

[McpServerToolType]
public static class UserDataTool
{
    [McpServerTool(Name = "GetUserDetailsFromGraph"), Description("Gets user details from Graph.")]
    public static async Task<string> GetUserDetailsFromGraph(
        IMcpServer thisServer,
        AuthenticationService authService,
        ILoggerFactory loggerFactory,
        CancellationToken cancellationToken)
    {
        var logger = loggerFactory.CreateLogger("UserDataTool");

        try
        {
            var tokenProvider = new TokenProvider(authService);
            var graphClient = new GraphServiceClient(
                        new BaseBearerTokenAuthenticationProvider(tokenProvider));

            var user = await graphClient.Me.GetAsync(cancellationToken: cancellationToken);

            if (user == null)
            {
                logger.LogWarning("No user data returned from Graph API");
                return "No user data available";
            }

            logger.LogInformation($"Retrieved user data for: {user.DisplayName}");

            return System.Text.Json.JsonSerializer.Serialize(user);
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "Error retrieving user details from Graph");
            return $"Error: {ex.Message}";
        }
    }
}

Here, I am also using the Microsoft Graph SDK and its GraphServiceClient to get user detail data from a predictable API - Microsoft Graph.

Additionally, you might’ve spotted that I’ve also implemented a TokenProvider, that does nothing other than wrap my authentication service provider (_authService) to get an access token through it.

public class TokenProvider : IAccessTokenProvider
{
    private readonly AuthenticationService _authService;

    public TokenProvider(AuthenticationService authService)
    {
        _authService = authService ?? throw new ArgumentNullException(nameof(authService));
        AllowedHostsValidator = new AllowedHostsValidator(new[] { "graph.microsoft.com" });
    }

    public async Task<string> GetAuthorizationTokenAsync(Uri uri, Dictionary<string, object> additionalAuthenticationContext = default,
        CancellationToken cancellationToken = default)
    {
        try
        {
            var accessToken = await _authService.AcquireTokenAsync();

            if (string.IsNullOrEmpty(accessToken))
            {
                throw new AuthenticationException("Failed to acquire access token");
            }

            return accessToken;
        }
        catch (Exception ex)
        {
            throw new AuthenticationException($"Error acquiring access token: {ex.Message}", ex);
        }
    }

    public AllowedHostsValidator AllowedHostsValidator { get; }
}

The authentication service provider, located in the AuthenticationService.cs file, is in turn, responsible for connecting my application to Entra ID with the help of MSAL. Its instantiation is defined in the server initialization logic:

builder.Services
    .AddSingleton(serviceProvider =>
    {
        var logger = serviceProvider.GetRequiredService<ILogger<AuthenticationService>>();
        return AuthenticationService.CreateAsync(logger).GetAwaiter().GetResult();
    })
    .AddMcpServer()
    .WithStdioServerTransport()
    .WithToolsFromAssembly();
await builder.Build().RunAsync();

Connecting to WAM #

The salient part, at least in my opinion, is the definition of the public client application that I am trying to use. As I mentioned above, all of this is handled inside AuthenticationService.cs, starting with the initialization logic:

public static async Task<AuthenticationService> CreateAsync(ILogger<AuthenticationService> logger)
{
    var storageProperties =
        new StorageCreationPropertiesBuilder("authcache.bin", Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Den.Dev.LocalMCP.WAM"))
        .Build();

    logger.LogInformation("Initializing AuthenticationService");

    var msalClient = PublicClientApplicationBuilder
        .Create(_clientId)
        .WithAuthority(AadAuthorityAudience.AzureAdMyOrg)
        .WithTenantId("b811a652-39e6-4a0c-b563-4279a1dd5012")
        .WithParentActivityOrWindow(GetConsoleOrTerminalWindow)
        .WithBroker(new BrokerOptions(BrokerOptions.OperatingSystems.Windows))
        .Build();

    var cacheHelper = await MsalCacheHelper.CreateAsync(storageProperties);
    cacheHelper.RegisterCache(msalClient.UserTokenCache);

    return new AuthenticationService(logger, msalClient);
}

Looking at this snippet, you will see that the first thing I do is use some helpers from Microsoft.Identity.Client.Extensions.Msal to create a location for my token cache. This will be an encrypted local file where MSAL will be able to store relevant credential material.

Next, I am setting up the PCA with the help of PublicClientApplicationBuilder, where I not only provide my client and tenant information, but also am explicit about the fact that I want to use an authentication broker - a component of the operating system on Windows that essentially “brokers” calls to Entra ID on behalf of the application, removing the need to worry about more complex security implementation details, like refresh token binding. It’s this part here:

.WithParentActivityOrWindow(GetConsoleOrTerminalWindow)
.WithBroker(new BrokerOptions(BrokerOptions.OperatingSystems.Windows))
Mole thinking.

You’re using BrokerOptions.OperatingSystem.Windows. Does that mean that there are brokers on other operating systems too?

The security team at Microsoft is working on making those more broadly accessible through MSAL, but yes, that is indeed the case! On iOS and Android, Microsoft Authenticator plays the role of the authentication broker. On macOS, that honor falls on the Company Portal app.

Mole holding a stop sign.

OK, so brokers are there - but don’t I also need to configure my application to be able to use them? Before I go and start testing my code, I recall that I need to set the redirect URIs in a special format.

Correct! For your application registration in Entra ID, you need to make sure that you have a redirect URI set up in this format:

ms-appx-web://microsoft.aad.brokerplugin/YOUR_CLIENT_ID

You can grab the client ID from the application overview in the Entra or Azure portals.

Getting the parent window to use with WAM #

Now this is the truly tricky part. To use WAM, you need to be able to bind it to a parent window. That is, whatever process is spinning it up is responsible for providing a window handle that WAM can parent itself to. The big benefit of this is that the WAM interactive prompt will show up in the context that the user expects. Going back to my Xbox application example, if I click “Sign in” anywhere in the app, I expect the sign-in window to be somewhere in close proximity and not on another display or under another window altogether.

To get the window handle, I devised this very convoluted logic:

private static IntPtr GetConsoleOrTerminalWindow()
{
    _logger.LogInformation("Attempting to get console or terminal window handle");
    
    // Attempt 1: Get console window handle
    IntPtr consoleHandle = NativeBridge.GetConsoleWindow();
    _logger.LogInformation($"Console handle: {consoleHandle}");
    
    // If we have a valid console handle, try to get its ancestor
    if (consoleHandle != IntPtr.Zero)
    {
        IntPtr ancestorHandle = NativeBridge.GetAncestor(consoleHandle, NativeBridge.GetAncestorFlags.GetRootOwner);
        _logger.LogInformation($"Ancestor handle: {ancestorHandle}");
        
        if (ancestorHandle != IntPtr.Zero)
        {
            return ancestorHandle;
        }
        else
        {
            _logger.LogWarning("GetAncestor returned zero, falling back to console handle");
            return consoleHandle; // Return console handle as fallback
        }
    }
    
    // Attempt 2: Try to get parent process window handle
    try
    {
        using var currentProcess = System.Diagnostics.Process.GetCurrentProcess();
        _logger.LogInformation($"Current process ID: {currentProcess.Id}");
        
        // Try to get the current process main window handle first
        if (currentProcess.MainWindowHandle != IntPtr.Zero)
        {
            _logger.LogInformation($"Using current process main window handle: {currentProcess.MainWindowHandle}");
            return currentProcess.MainWindowHandle;
        }
        
        // Otherwise, try parent process
        var parentProcessId = NativeBridge.GetParentProcessId(currentProcess.Id);
        _logger.LogInformation($"Parent process ID: {parentProcessId}");
        
        if (parentProcessId != 0)
        {
            try
            {
                using var parentProcess = System.Diagnostics.Process.GetProcessById(parentProcessId);
                var parentHandle = parentProcess.MainWindowHandle;
                _logger.LogInformation($"Parent process handle: {parentHandle}");
                
                if (parentHandle != IntPtr.Zero)
                {
                    return parentHandle;
                }
            }
            catch (ArgumentException ex)
            {
                _logger.LogWarning($"Parent process {parentProcessId} no longer exists: {ex.Message}");
            }
            catch (Exception ex)
            {
                _logger.LogError($"Error accessing parent process: {ex.Message}");
            }
        }
        
        // Attempt 3: Find a suitable window from all running processes (last resort)
        _logger.LogInformation("Attempting to find other suitable window handles");
        var processes = System.Diagnostics.Process.GetProcesses();
        foreach (var possibleParent in processes)
        {
            try
            {
                // Look for known terminal or shell processes
                if ((possibleParent.ProcessName.Contains("cmd") || 
                      possibleParent.ProcessName.Contains("powershell") ||
                      possibleParent.ProcessName.Contains("terminal") ||
                      possibleParent.ProcessName.Contains("explorer")) && 
                    possibleParent.MainWindowHandle != IntPtr.Zero)
                {
                    _logger.LogInformation($"Found potential parent window in {possibleParent.ProcessName}: {possibleParent.MainWindowHandle}");
                    return possibleParent.MainWindowHandle;
                }
            }
            catch (Exception)
            {
                continue;
            }
            finally
            {
                possibleParent.Dispose();
            }
        }
    }
    catch (Exception ex)
    {
        _logger.LogError($"Failed to retrieve any window handle: {ex.Message}");
    }
    
    _logger.LogWarning("Returning IntPtr.Zero as window handle - authentication may require additional user interaction");
    return IntPtr.Zero;
}

This logic is a bit janky, especially on that third attempt when it looks for other terminal or shell processes, but it works OK in my tests, like this one with Claude Desktop (if it doesn’t for you - let me know):

I don’t like that the window pops up in the background, so I will need to come up with a more robust solution, but for demo purposes this does the trick.

As you can see, until the tool on the MCP server side is invoked, I am never actually asked to enter my credentials. However, once the tool is invoked, the MCP server realizes that it doesn’t have any stored tokens for me and therefore needs to request me to go through the authentication flow first.

And that’s about all you need to get local MCP servers to use Entra ID credentials connected to a Windows computer!

Conclusions #

Local MCP servers can be treated like any other application that you are developing for local execution. They have access to the same authentication libraries and capabilities like any other desktop app, which means that for authentication and authorization you don’t need to worry about the intricacies of OAuth or how clients need to talk to servers and exchange credentials - all of that can done exclusively within the MCP server and with established tools.